[CVE-2023–38743] ManageEngine ADManager Command Injection

Petrus Viet
7 min readOct 2, 2023

--

Authenticated users with admin privileges can run an arbitrary command on the host machine in which ADManager Plus is installed.

Vào hồi tháng 4 năm nay, mình rơi vào giai đoạn down mood kha khá vì thế nên mình đi tìm cái gì đó để làm cho nó vui (hoặc để chạy KPI 😰). Mình đã chọn các sản phẩm của ManageEngine, đặc điểm của chúng là phần core sẽ được dùng chung cho nhiều product khác nhau vậy nên maybe là khi mình tìm được bug của thằng A thì thằng B, C, D,… cũng có khả năng ảnh hưởng.

I) Debug

  • Sửa file: ./conf/wrapper.conf
#uncomment the following to enable JPDA debugging
wrapper.java.additional.17=-Xdebug
wrapper.java.additional.18=-Xnoagent
wrapper.java.additional.19=-Xrunjdwp:transport=dt_socket,address=8787,server=y,suspend=n

II) Phân tích

Khi tìm kiếm các bug trước đây của ManageEngine và đọc sơ code của nó thì mình nhận ra nó gọi system command khá nhiều và có thể đâu đó còn nhiều bug command injection vậy nên mình quyết định đi tìm loại bug command injection trước.

Có 2 cách chạy lệnh ABC với cmd.exe như sau:

1) "cmd.exe" "ABC" "arg1" "arg2"
2) "cmd.exe" "/c" "ABC arg1 arg2"
  • Với cách gọi 1), hệ thống sẽ thực thi lệnh ABC với các Args được truyền vào cho process chạy câu lệnh ABC là arg1 arg2. Điều đó có nghĩa là nếu attacker kiểm soát được arg1 hoặc arg2 thì toàn bộ payload truyền vào sẽ chỉ được hệ thống coi như là Args của câu lệnh ABC ⇒ không thể break và chạy một câu lệnh khác mà attacker mong muốn.
  • Với cách gọi 2), hệ thống coi “ABC arg1 arg2” như là một đoạn script điều đó khiến attacker có quyền kiểm soát arg1 hoặc arg2 có thể thực thi câu lệnh tùy ý của mình bằng cách sử dụng các ký tự như &, |, `, … để thoát khỏi ngữ cảnh của câu lệnh ABC.
cmd /?
Starts a new instance of the Windows command interpreter

CMD [/A | /U] [/Q] [/D] [/E:ON | /E:OFF] [/F:ON | /F:OFF] [/V:ON | /V:OFF]
[[/S] [/C | /K] string]
/C Carries out the command specified by string and then terminates
/K Carries out the command specified by string but remains

Điều đó có nghĩa là khi mình search string để tìm kiếm những chỗ gọi CMD thì thay vì mình tìm từ khóa “cmd.exe” hay “cmd” thì mình tìm luôn “cmd /c”, “cmd.exe /c” hay như trong bug này thì mình tìm luôn từ khóa “/c”.

Ta tìm được một đoạn code như sau:

Iterator i$ = ProcessUtil.executeAndGetOutput(false, "cmd", "/c start /b wrapper.exe -i ..\\conf\\wrapper.conf wrapper.ntservice.account=" + cred.optString("DOMAIN", (String)null) + "\\" + cred.optString("ACCOUNT_NAME", (String)null) + " wrapper.ntservice.password=" + cred.optString("PASSWORD", (String)null)).iterator();
  • Đi theo hàm ProcessUtil.executeAndGetOutput Ta nhận thấy input được đưa vào ProcessBuilder để thực thi:

Như vậy nếu chúng ta có thể control được các giá trị cred[”DOMAIN”], cred[”ACCOUNT_NAME”] hoặc cred[”PASSWORD”] thì rất có thể ở đây có lỗi command injection.

Mình sử dụng ctrl+f7 trong intellij để trace ngược lại hàm source khá là dễ dàng:

com.manageengine.ads.fw.ha.HAClusterServlet.doPost()  
-> com.manageengine.ads.fw.ha.HAClusterServlet.installService()
-> com.manageengine.ads.fw.util.WrapperUtil.installServiceWithCredentials()
-> ProcessUtil.executeAndGetOutput()

Đi vào xem file web.xml thì ta có thể biết được HAClusterServlet handle endpoint nào:

<servlet>
<servlet-name>ADSHACluster</servlet-name>
<servlet-class>com.manageengine.ads.fw.ha.HAClusterServlet</servlet-class>
</servlet>
[...]
<servlet-mapping>
<servlet-name>ADSHACluster</servlet-name>
<url-pattern>/servlet/ADSHACluster</url-pattern>
</servlet-mapping>

Tại HAClusterServlet.doPost(), ta có:

public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String server = null;
JSONObject clusterSettings = null;
JSONObject status = new JSONObject();
JSONObject credentialObj = null;
boolean isAllowed = false; // [0]
JSONObject authStatus = null;

try {
String mTCall = request.getParameter("MTCALL");
String credential = request.getParameter("credential") != null ? HAClusterUtil.decodeBase64Param(request.getParameter("credential")) : "";
String haAuthKey = request.getParameter("haAuthKey") != null ? request.getParameter("haAuthKey") : "";
String productName = request.getParameter("productName") != null ? HAClusterUtil.decodeBase64Param(request.getParameter("productName")) : CommonUtil.getProductName();
if (credential != null && !credential.equals("") && credential.length() != 0) {
credentialObj = new JSONObject(credential);
}
String password;
String handshake;
if (credentialObj != null && credentialObj.length() != 0) { // [1]
handshake = credentialObj.get("TECHNICIAN_NAME") != null ? (String)credentialObj.get("TECHNICIAN_NAME") : "";
password = credentialObj.get("TECHNICIAN_PASSWORD") != null ? (String)credentialObj.get("TECHNICIAN_PASSWORD") : "";
authStatus = this.authenticate(handshake, password);
isAllowed = ((String)authStatus.get("STATUS")).equals("success");
authStatus.put("AUTH_ERROR", true);
} else if (haAuthKey != null && !haAuthKey.equals("")) { // [2]
Long productId = CommonUtil.getProductId(productName);
password = CommonUtil.getProductParamValue(productId, "HA_AUTH_KEY");
if (password != null && haAuthKey.equals(password)) {
isAllowed = true;
}
}
if (isAllowed) { // [3]
if (mTCall.equalsIgnoreCase("exists")) {
status = this.isClusterExists();
} else if (mTCall.equalsIgnoreCase([...])) {[...]}
} else {
if (authStatus == null) {
authStatus = new JSONObject();
}
authStatus.put("STATUS", "error");
status = authStatus;
if (mTCall.equalsIgnoreCase("getFOSNodeDetails")) {
status = new JSONObject();
}
}
response.setContentType("application/json");
PrintWriter out = response.getWriter();
out.println(status);
out.flush();
} catch (Exception var15) {
logger.log(Level.INFO, " ", var15);
}
}
  • Tại [0]: Chương trình khai báo biến isAllowed với giá trị mặc định là false
  • Tại [1]: Nếu user cung cấp credential gồm TECHNICIAN_NAME TECHNICIAN_PASSWORD thì kiểm tra xem credential đó có phải là của một TECHNICIAN (admin) user hay không? nếu có thì isAllowed = true
  • Tại [2]: Nếu user không cung cấp credential tại [1] nhưng có cung cấp haAuthKey chính xác thì isAllowed = true
  • Tại [3]: Nếu isAllowed = true thì chương trình bắt đầu chọn func để gọi bằng cách xét biến mTCall mà user cung cấp. Nếu như isAllowed = false, chương trình response về lỗi.

Điều đó có nghĩa là để sử dụng được HAClusterServlet.doPost(), ta phải có được quyền admin.

Để chương trình gọi tới HAClusterServlet.installService() thì mTCall phải là “installService

else if (mTCall.equalsIgnoreCase("installService")) {
status = this.installService(request);
}

Tại HAClusterServlet.installService() ta thấy chương trình lấy các parameter “DOMAIN”, “USER_NAME” và “PASSWORD” rồi decode sau đó đưa vào biến cred

private JSONObject installService(HttpServletRequest request) {
JSONObject status = new JSONObject();

try {
status.put("STATUS", "error");
if (request.getParameter("DOMAIN") != null) {
JSONObject cred = new JSONObject();
cred.put("ACCOUNT_NAME", HAClusterUtil.decodeBase64Param(request.getParameter("USER_NAME")));
cred.put("PASSWORD", HAClusterUtil.decodeBase64Param(request.getParameter("PASSWORD")));
cred.put("DOMAIN", request.getParameter("DOMAIN"));
if (WrapperUtil.installServiceWithCredentials(cred)) {
status.put("STATUS", "success");
}
} else {
WrapperUtil.installAsService();
status.put("STATUS", "success");
}
} catch (Exception var4) {
logger.log(Level.SEVERE, "Exception in HAClusterServlet.installService() - > " + var4.getMessage());
}
return status;

⇒ Như vậy là đã có thể inject payload của mình vào command rồi nhỉ? Maybe 😇

Tại WrapperUtil.installServiceWithCredentials() ta thấy:

public static boolean installServiceWithCredentials(JSONObject cred) {
try {
// [4]
if (!ADSNativeHandler.authenticateADCredentials(cred.optString("DOMAIN", (String)null), cred.optString("ACCOUNT_NAME", (String)null), cred.optString("PASSWORD", (String)null))) {
return false;
}

isServiceInstalled = false;
// [5]
Iterator i$ = ProcessUtil.executeAndGetOutput(false, "cmd", "/c start /b wrapper.exe -i ..\\conf\\wrapper.conf wrapper.ntservice.account=" + cred.optString("DOMAIN", (String)null) + "\\" + cred.optString("ACCOUNT_NAME", (String)null) + " wrapper.ntservice.password=" + cred.optString("PASSWORD", (String)null)).iterator();
while(i$.hasNext()) {
String line = (String)i$.next();
if (line.contains(HAClusterUtil.getWrapperProperty("wrapper.ntservice.displayname")) && !line.contains("not installed")) {
isServiceInstalled = true;
break;
}
}
} catch (Exception var3) {
var3.printStackTrace();
}
return isServiceInstalled;
}
  • Tại [4] ta thấy chương trình đưa vào ADSNativeHandler.authenticateADCredentials() . Đây là một hàm native nên mình không debug tiếp được nữa. Nhìn tên hàm và debug để thử các input thì mình nhận thấy hàm này dùng để authen AD Credentials, ở đây chính là một Account trong mạng AD. Nếu credentials hợp lệ thì mới được chuyển tới ProcessUtil.executeAndGetOutput

Như vậy mình cần có những tham số sau:

- MTCALL=installService
- credential = base64({"TECHNICIAN_NAME":"admin","TECHNICIAN_PASSWORD":"admin"})
- USER_NAME = base64(`AD_USERNAME`)
- PASSWORD = base64(`AD_PASSWORD`)
- DOMAIN = `AD_DOMAIN `

Nếu mình chèn payload vào DOMAIN khiến nó trở thành một DOMAIN không tồn tại:

  • Với trường hợp ADManager được deploy trên máy Domain Controller, chỉ cần username password đúng thì hàm ADSNativeHandler.authenticateADCredentials sẽ trả về true
  • Với trường hợp ADManager được deploy trên một máy thuộc một mạng AD nhưng không phải Domain Controller, nếu DOMAIN truyền vào không đúng thì chương trình sẽ kiểm tra với các account local.

May thay, ADManager cho phép quản lý các tài khoản AD, vì thế với quyền admin mình có thể tự tạo một tài khoản AD với mật khẩu chính là payload của mình và như thế thì đã RCE thành công.

Thực ra bug này mình tìm thấy trên một product khác của ManageEngine, nhưng vì lý do ngoài việc cần có tài khoản admin ra thì attacker cần phải có một tài khoản AD nữa để khai thác RCE, nên mình chuyển qua bên ADManager 😄😄😄

III) Kết bài

Bug này thực ra impact không cao, nhưng mình vẫn viết blog vì mình nghĩ đâu đó sẽ có bạn sinh viên đang cần. 😄 Bắt tay làm nday, 1day rất hữu dụng để cho các bạn có thêm “kiến thức, kinh nghiệm và trải nghiệm” để tìm được 0day. Đó là một quá trình dài cần nhiều sự kiên trì, và nếu một bạn beginner có người hướng dẫn hay cóthêm tài liệu để nghiên cứu thì có lẽ các bạn sẽ đỡ cái cảm giác chán nản đi. Mình chỉ mong sẽ có nhiều người đóng góp thêm vào open source của Việt Nam hơn chứ không mong muốn khoe những bug có độ impact khủng (hoặc là do không có 😢). Cảm ơn các bạn đã đọc tới đây 🤓

--

--